Hummer 引擎优化系列 - Flutter SurfaceView 动态切换方案
因为很多应用在使用 Flutter 上是一种同时使用原生和 Flutter 的混合模式,所以较大概率还是选择了性能较差的 TextureView 而不是 SurfaceView 去规避一些渲染瑕疵的问题。虽然我们在 Hummer 上也针对 TextureView 的一些问题做了性能优化,但是由于 TextureView 和 SurfaceView 在基础渲染机制上存在较大的差异,SurfaceView 对比 TextureView 还是存在很多优势。
我们也考察了某些业务较为简单的 Flutter 使用场景(FlutterView 全屏,大小位置固定,不需要跟 Native View 做半透明混合),是否存在使用 SurfaceView 的可能。考察的结果是,虽然直接使用 SurfaceView 存在一定的问题,但是如果引擎提供一种可以动态切换 TextureView/SurfaceView 的机制,在绝大部分情况下,是可以使用上 SurfaceView 的。那么问题就回到了我们作为引擎的开发者,是否能够提供一种动态切换的机制,让应用在需要时可以将 FlutterView 内部使用的 TextureView promote 到 SurfaceView,或者将已经 promote 的 SurfaceView 再 fallback 回 TextureView。而且这个 promote/fallback 的过程足够高效,基本无阻塞,不会导致卡顿,并且是无缝切换,不会有闪烁,闪黑,闪白等现象。
在后续文章中,我们会先从渲染机制的原理说明使用 SurfaceView 的优势,然后再说明我们的 SurfaceView 动态切换方案,和提供给应用的 API。
SurfaceView vs TextureView
无论是 SurfaceView 还是 TextureView,它们在 Flutter 里面的作用都是为 Flutter 的光栅化引擎提供一个输出的 Surface,那它们的差异在哪里呢?Android Surface 底层的实现都是基于 BufferQueue,而 BufferQueue 自然存在一个生产者和一个消费者,在 Flutter 中,无论是 SurfaceView 还是 TextureView,它们提供的 Surface/BufferQueue 生产者都是 Flutter 的 Rasterizer(光栅化器),所以差别在于它们的消费者。
SurfaceView 提供的是一个 Onscreen Surface(或者称为 Onscreen Window),它的消费者就是 Android 系统的窗口合成器 SurfaceFlinger, 对于 SurfaceView 提供的 Surface,Flutter 绘制完一个新的 Buffer 并提交后,这个 Buffer 就直接通过 SurfaceFlinger 更新到屏幕上了;
TextureView 提供的是一个 Offscreen Surface(或者称为 Offscreen Window),它的消费者是 Android UI Renderer,对于 TextureView 提供的 Surface,Flutter 绘制完一个新的 Buffer 并提交后,这个 Buffer 还需要跟其它 Android View 一起,由 Android UI Renderer 再绘制到当前 View 树所属的 Onscreen Surface(或者称为 Onscreen Window)上,然后再由 SurfaceFlinger 更新到屏幕上;
所以 SurfaceView vs TextureView,它们根本的差别在于:
SurfaceView 提供的 Surface 的绘制输出,只需要 Flutter Raster 一个线程参与,只需要一次 Render Pass,最少可以在一个 vsync 周期内完成;
TextureView 提供的 Surface 的绘制输出,需要 Flutter Raster 线程,Android UI 线程,Android Render 线程三个线程共同参与,需要两次 Render Pass,最少需要两个 vsync 周期才能完成;
所以使用 TextureView,就意味着在性能,耗电,输出延迟方面对比 SurfaceView 都存在一定的劣势。
TextureView 对比 SurfaceView 的性能劣势主要是以下几方面:
a) 第一方面 TextureView 每一帧需要两次 Render Pass(通常在没有太多 Native View 的情况下,Android UI Renderer 绘制的 Render Pass 开销会比较小一些),意味着更高的 GPU 和内存带宽开销,如果绘制内容的 GPU 负荷比较高,或者手机的 GPU 性能较差,或者手机出现过热锁频的情况,TextureView 更容易在 Flutter Raster 线程和 Android Render 线程这两个 GPU 线程被阻塞,导致卡顿;
b) 第二方面 TextureView 的绘制涉及三个线程的流水线调度,容易出现调度的同步问题导致掉帧(这也是我们 TextureView 性能优化所优化的部分);
c) 第三方面应用如果在 Android UI 线程出现非 UI 任务的阻塞,也会导致 TextureView 的绘制掉帧,但是 SurfaceView 则完全不受影响,我们之前在实际业务中也碰到过这种情况;TextureView 每一帧需要两次 Render Pass,意味着更高的 GPU 和内存带宽开销,同时也意味着耗电更多;
TextureView 每一帧需要两个 vsync 周期完成绘制,加上 SurfaceFlinger 的输出,它的输出延迟最少需要三个 vsync 周期,而 SurfaceView 则只需要两个 vsync 周期,并且因为 SurfaceView 掉帧的概率更低,所以它的输出延迟也更稳定,虽然一般来说输出延迟增加一个 vsync 周期对普通应用差别非常小,用户基本难以察觉,不过对于强交互的场景,比如使用 Flutter 开发的 2D 游戏,SurfaceView 还是更好的选择。
总的来说,对于内容复杂、高动态变化比如长列表或者多标签页面,还有强交互页面等,使用 SurfaceView 可以让我们有机会获得真正超越原生的性能。
TextureView 绘制的细节我们会在后面的文章再进行解析。
基于 ImageReader 的 ImageView,渲染机制跟 TextureView 大部分情况下还是差不多的,所以对比 SurfaceView 还是会有性能耗电等问题,但是对比 TextureView,它可以避免很多调度机制不合理导致的抖动不流畅等问题。本文中不再专门提及 ImageView,读者可以默认认为本文中的 TextureView 同时指代 TextureView 和 ImageView 两者。关于如何在 Hummer 中使用 ImageView 替代 TextureView,可以参考文章 FlutterImageView 使用说明。
SurfaceView 动态切换方案
SurfaceView 虽然在性能,耗电,输出延迟方面都存在一定优势,但是它在跟 Native View 混合渲染时又存在较大的问题。比如在跟随页面滚动,跟随页面做转场动画,需要频繁改变大小,需要跟 Native View 做半透明混合等各种场景都存在各种问题,另外多个 SurfaceView 创建的 Onscreen Window 存在无法有效地控制 z-index,导致可能出现无法预测的互相覆盖的问题,在早期 Android 7 以下的版本,多个 SurfaceView 共存还会引起一些额外的 UI 绘制问题。
对于一些特殊场景,比如 FlutterView 作为卡片嵌入原生长列表,需要频繁改变大小和跟随页面滚动,又比如 FlutterView 作为弹出框需要跟 Native View 做半透明混合。这些场景下使用 SurfaceView 的确不是一个明智的选择,并且因为它们的内容偏静态,所以使用 SurfaceView 也不会有什么收益。
而对于更常见的普通场景来说,如果我们解决好 SurfaceView 跟随页面动画的问题,解决好多个 SurfaceView 同时显示,它们创建的 Onscreen Window 互相覆盖的问题,那还是可以使用上 SurfaceView 的。
飞猪的这篇文章(https://www.infoq.cn/article/u116vkbzk2vbfgczyfbe)在应用端提供了一个使用截图作为过渡的使用 SurfaceView 的解决方案。而作为引擎团队,我们的思考是如何在引擎侧提供一个更容易使用,性能更好的方案。其实 SurfaceView 的动态切换方案我们在做 U4 的时候就有过思考,不过 WebView 的使用场景非常复杂,而且 WebView 的使用涉及引擎,容器,应用三方,外部适配起来比较麻烦,成本太高,所以就没有去尝试。而在 Flutter 上,目前大部分应用的 FlutterView 使用场景还是比较简单的,这个方案也只需要应用方做一些简单的适配就可以使用,总体成本比较低。
SurfaceView 动态切换方案简单描述如下:
FlutterView 默认使用 TextureView/ImageView 作为 RenderSurface;
对于适用 SurfaceView 的 FlutterView,应用在合适的时机调用引擎提供的 API 将 TextureView promote 为 SurfaceView,通常在页面导航转场动画结束后,FlutterView 进入稳定显示状态;
已经 promote 到 SurfaceView 的 FlutterView,应用在合适的时间调用引擎提供的 API fallback 回 TextureView,通常在页面导航转场动画开始前;
不适用 SurfaceView 的 FlutterView,或者不需要高性能的 FlutterView,保持不变;
RenderSurface Promote and Fallback
Hummer 引擎目前提供两个 API 给应用实现对 SurfaceView 动态切换的控制(fallback 提供了异步和同步的版本,所以实际上是三个):
FlutterView.promoteRenderSurface 请求 FlutterView 的 RenderSurface 从 TextureView 切换到 SurfaceView,它有一个返回值,PROMOTE_RENDER_SURFACE_SUCCESS 表示 promote 成功,其它值表示失败的原因,比如 PROMOTE_RENDER_SURFACE_NO_SURFACE 表示这个 FlutterView 还没有分配任何 RenderSurface;
FlutterView.fallbackRenderSurface 请求 FlutterView 的 RenderSurface 从 SurfaceView 切换回 TextureView,它有一个同步的版本 FlutterView.fallbackRenderSurfaceSync 应用于特殊场景,大部分情况下我们推荐使用默认的异步调用版本,不过少部分情况异步调用有问题的情况下可以使用同步的版本,在后面的幽默 Demo 的例子里面我们可以看到需要使用同步版本的一些场景;
FlutterView.promoteRenderSurface 和 FlutterView.fallbackRenderSurface 都是异步的调用,它们本身的主要开销是请求 Flutter Rasterizer 更换当前的输出 Surface,在一般 Flutter Raster 线程没有阻塞的情况下,这个开销绝大部分情况下不超过一个 vsync 周期,一般在 5~10ms 左右,所以基本上不会造成阻塞导致卡顿。
在 Promote 和 Fallback 的过程中,我们还需要请求 Flutter Rasterizer 在更换 Surface 之后,再重新绘制上一帧的内容,保证更换后的 Surface 的内容跟之前的 Surface 是完全一致的,这个耗时一般在一个 vsync 周期左右,不过因为异步的关系,这个耗时通常不会造成应用主线程,也就是 Android UI 线程的阻塞。不过即使是同步 fallback,加上重复绘制一帧的开销,通常整体开销也就是 20~30ms,影响其实也很小。
可以看到,SurfaceView 动态切换方案对比应用使用截图过渡的方案,使用上更简单,不需要大量的适配代码,并且对比截图所需要的上百毫秒甚至几百毫秒的开销,性能上更是有着巨大的优势。下面是一个基于 FlutterGallay 的修改 Demo 视频,我们修改的代码定时控制 FlutterView 在 SurfaceView 和 ImageView 之前来回切换(下标调试信息 SV 代表 SurfaceView,IV 代表 ImageView),并且 FlutterGallay 在各种操作中不间断运行各种动画,我们可以看到 SurfaceView 动态切换机制在 FlutterView 的连续操作和动画过程中完全实现了无卡顿和无缝的动态切换。
性能测试结果
我们进行多轮性能测试对比 SurfaceView vs TextureView,结果如下:
在使用 TextureView 还没有碰到 GPU/内存带宽瓶颈的情况下,SurfaceView 带来的提升较为有限,大约在 5% 以内,1~2 帧左右提升;
当 TextureView 碰到 GPU/内存带宽性能瓶颈,帧率和实际流畅度下降明显,这时 SurfaceView 仍然保持一个较好的流畅水平,帧率对比 TextureView 高 10% ~ 20%,考虑到 GPU/内存带宽瓶颈带来的阻塞不仅仅是应用端的 GPU 线程,还影响到服务端 SurfaceFlinger 的 GPU 线程,所以实际的流畅度对比应该比单纯帧率的对比幅度更大;
对于绘制比较复杂的 Flutter 页面,在中低端手机上,或者手机出现严重过热降频,使用 TextureView 触发 GPU/内存带宽瓶颈的概率还是比较高的。另外 SurfaceView 耗电上的优势也减少了过热降频的概率。
业务方的接入使用
业务方接入使用 Hummer 的 SurfaceView 动态切换方案,大概需要做三个方面的事情:
设计一套机制用于判断某个 FlutterView 是否需要使用 SurfaceView 动态切换,排除掉一些不适用 SurfaceView 或者不需要 SurfaceView 性能的场景,比如可以通过判断业务类型,FlutterView 的展现形态是嵌入式卡片,弹出框还是全屏窗口等;
当前应用是否有一些机制可以判断 Flutter 窗口的状态,类似上面的 UC 浏览器的窗口管理机制;
在合适的状态时机调用 Hummer 提供的 API,参考上面的适配 Demo;